game_manager_lib\commands\recommendation/
core.rs

1//! Comandos Tauri para Sistema de Recomendação v4.0
2//!
3//! Faz JOIN com game_details para obter genres, tags categorizadas e series.
4//! Utiliza abordagem híbrida: perfil do usuário + collaborative filtering.
5//! Permite configuração de filtros (playtime), pesos personalizados, feedback (blacklist).
6//! Retorna razões detalhadas para cada recomendação.
7
8use crate::database::AppState;
9use crate::errors::AppError;
10use crate::models::Game;
11use crate::services::recommendation::{
12    calculate_user_profile, parse_release_year, rank_games_collaborative, rank_games_content_based,
13    rank_games_hybrid, GameWithDetails, RecommendationConfig, RecommendationReason, SeriesLimit,
14    UserPreferenceVector, UserSettings,
15};
16use serde::{Deserialize, Serialize};
17use std::collections::HashSet;
18use tauri::{Manager, State};
19
20// === ESTRUTURAS DE DADOS ===
21
22/// Estrutura completa de recomendação para o Frontend
23#[derive(Debug, Serialize)]
24pub struct GameRecommendation {
25    pub game_id: String,
26    pub score: f32,
27    pub reason: RecommendationReason,
28}
29
30/// Struct auxiliar para input de configuração opcional
31#[derive(Debug, Deserialize)]
32pub struct RecommendationOptions {
33    pub min_playtime: Option<i32>,
34    pub max_playtime: Option<i32>,
35    pub limit: usize,
36    pub ignored_game_ids: Option<Vec<String>>, // Blacklist do feedback
37    pub config: Option<RecommendationConfig>,  // Pesos personalizados
38}
39
40// === COMANDOS PRINCIPAIS ===
41
42/// Comando Híbrido Principal (Usado na Playlist e Home)
43///
44/// Retorna recomendações de jogos baseadas em perfil do usuário + collaborative filtering.
45#[tauri::command]
46pub async fn recommend_hybrid_library(
47    app: tauri::AppHandle,
48    state: State<'_, AppState>,
49    options: RecommendationOptions,
50) -> Result<Vec<GameRecommendation>, AppError> {
51    let games_with_details = fetch_all_games_with_details(&state)?;
52    let ignored_ids = create_ignored_set(options.ignored_game_ids.clone());
53    let profile = calculate_user_profile(&games_with_details, &ignored_ids);
54    let (cf_scores, _) = crate::services::cf_aggregator::build_cf_candidates(&games_with_details);
55    let candidates = filter_candidates_by_playtime(games_with_details, &options);
56    let config = options.config.unwrap_or_default();
57    let user_settings = load_user_settings(&app);
58
59    let ranked = rank_games_hybrid(
60        &profile,
61        &candidates,
62        &cf_scores,
63        &ignored_ids,
64        config,
65        user_settings,
66    );
67
68    Ok(format_recommendations(ranked, options.limit))
69}
70
71/// Recomendação Content-Based Pura (Biblioteca)
72#[tauri::command]
73pub async fn recommend_from_library(
74    app: tauri::AppHandle,
75    state: State<'_, AppState>,
76    options: RecommendationOptions,
77) -> Result<Vec<GameRecommendation>, AppError> {
78    let games_with_details = fetch_all_games_with_details(&state)?;
79    let ignored_ids = create_ignored_set(options.ignored_game_ids.clone());
80    let profile = calculate_user_profile(&games_with_details, &ignored_ids);
81    let candidates = filter_candidates_by_playtime(games_with_details, &options);
82    let config = options.config.unwrap_or_default();
83    let user_settings = load_user_settings(&app);
84    let ranked = rank_games_content_based(&profile, &candidates, &config, &user_settings);
85
86    Ok(format_recommendations(ranked, options.limit))
87}
88
89/// Recomendação Collaborative Pura
90#[tauri::command]
91pub async fn recommend_collaborative_library(
92    app: tauri::AppHandle,
93    state: State<'_, AppState>,
94    options: RecommendationOptions,
95) -> Result<Vec<GameRecommendation>, AppError> {
96    let games_with_details = fetch_all_games_with_details(&state)?;
97    let ignored_ids = create_ignored_set(options.ignored_game_ids.clone());
98    let (cf_scores, _) = crate::services::cf_aggregator::build_cf_candidates(&games_with_details);
99    let candidates = filter_candidates_by_playtime(games_with_details, &options);
100    let user_settings = load_user_settings(&app);
101    let ranked = rank_games_collaborative(&candidates, &cf_scores, &ignored_ids, &user_settings);
102
103    Ok(format_recommendations(ranked, options.limit))
104}
105
106/// Retorna informações sobre o perfil do usuário
107#[tauri::command]
108pub async fn get_user_profile(
109    state: State<'_, AppState>,
110) -> Result<UserPreferenceVector, AppError> {
111    let games_with_details = fetch_all_games_with_details(&state)?;
112    let ignored_ids = HashSet::new();
113    let profile = calculate_user_profile(&games_with_details, &ignored_ids);
114    Ok(profile)
115}
116
117// === FUNÇÕES AUXILIARES ===
118
119/// Carrega as configurações de usuário do arquivo JSON
120fn load_user_settings(app_handle: &tauri::AppHandle) -> UserSettings {
121    // Tentar ler o arquivo de preferências
122    let app_data_dir = match app_handle.path().app_data_dir() {
123        Ok(dir) => dir,
124        Err(_) => return UserSettings::default(),
125    };
126
127    let prefs_path = app_data_dir.join("user_preferences.json");
128
129    if !prefs_path.exists() {
130        return UserSettings::default();
131    }
132
133    let contents = match std::fs::read_to_string(&prefs_path) {
134        Ok(c) => c,
135        Err(_) => return UserSettings::default(),
136    };
137
138    let prefs: serde_json::Value = match serde_json::from_str(&contents) {
139        Ok(p) => p,
140        Err(_) => return UserSettings::default(),
141    };
142
143    let filter_adult = prefs
144        .get("filter_adult_content")
145        .and_then(|v| v.as_bool())
146        .unwrap_or(false);
147
148    let series_limit_str = prefs
149        .get("series_limit")
150        .and_then(|v| v.as_str())
151        .unwrap_or("moderate");
152
153    let series_limit = match series_limit_str {
154        "none" => SeriesLimit::None,
155        "aggressive" => SeriesLimit::Aggressive,
156        _ => SeriesLimit::Moderate,
157    };
158
159    UserSettings {
160        filter_adult_content: filter_adult,
161        series_limit,
162    }
163}
164
165fn fetch_all_games_with_details(state: &AppState) -> Result<Vec<GameWithDetails>, AppError> {
166    let conn = state.library_db.lock()?;
167
168    let mut stmt = conn.prepare(
169        "SELECT
170            g.id, g.name, g.playtime, g.favorite, g.user_rating, g.cover_url,
171            g.platform_id, g.last_played, g.added_at, g.platform,
172            gd.genres, gd.steam_app_id, gd.release_date, gd.series, gd.tags
173         FROM games g
174         LEFT JOIN game_details gd ON g.id = gd.game_id
175         ORDER BY g.name ASC",
176    )?;
177
178    let games: Result<Vec<GameWithDetails>, _> = stmt
179        .query_map([], |row| {
180            let game = Game {
181                id: row.get(0)?,
182                name: row.get(1)?,
183                playtime: row.get(2)?,
184                favorite: row.get(3)?,
185                user_rating: row.get(4)?,
186                cover_url: row.get(5)?,
187                platform_id: row.get(6)?,
188                last_played: row.get(7)?,
189                added_at: row.get(8)?,
190                platform: row
191                    .get::<_, String>(9)
192                    .unwrap_or_else(|_| "Unknown".to_string()),
193                // Campos não utilizados mas necessários
194                genres: None,
195                developer: None,
196                install_path: None,
197                executable_path: None,
198                launch_args: None,
199                status: None,
200                is_adult: false,
201            };
202
203            let genres_json: Option<String> = row.get(10)?;
204            let genres: Vec<String> = genres_json
205                .as_ref()
206                .map(|s| {
207                    // Tentar parsear como JSON primeiro
208                    if let Ok(vec) = serde_json::from_str::<Vec<String>>(s) {
209                        vec
210                    } else {
211                        // Fallback: parsear como comma-separated string
212                        s.split(',')
213                            .map(|g| g.trim().to_string())
214                            .filter(|g| !g.is_empty())
215                            .collect()
216                    }
217                })
218                .unwrap_or_default();
219
220            let steam_app_id_str: Option<String> = row.get(11)?;
221            let steam_app_id: Option<u32> = steam_app_id_str.and_then(|s| s.parse().ok());
222
223            let release_date: Option<String> = row.get(12)?;
224            let release_year = release_date.and_then(|d| parse_release_year(&d));
225
226            let series: Option<String> = row.get(13)?;
227
228            // Buscar tags do JSON na coluna tags
229            let tags_json: Option<String> = row.get(14)?;
230            let tags: Vec<crate::models::GameTag> = tags_json
231                .as_ref()
232                .and_then(|s| serde_json::from_str(s).ok())
233                .unwrap_or_default();
234
235            Ok(GameWithDetails {
236                game,
237                genres,
238                tags,
239                series,
240                release_year,
241                steam_app_id,
242            })
243        })?
244        .collect();
245
246    games.map_err(|e| e.into())
247}
248
249fn create_ignored_set(ignored_game_ids: Option<Vec<String>>) -> HashSet<String> {
250    ignored_game_ids.unwrap_or_default().into_iter().collect()
251}
252
253fn filter_candidates_by_playtime(
254    games: Vec<GameWithDetails>,
255    options: &RecommendationOptions,
256) -> Vec<GameWithDetails> {
257    let min = options.min_playtime.unwrap_or(0);
258    let max = options.max_playtime.unwrap_or(999999);
259
260    games
261        .into_iter()
262        .filter(|g| {
263            let pt = g.game.playtime.unwrap_or(0);
264            pt >= min && pt <= max
265        })
266        .collect()
267}
268
269fn format_recommendations(
270    ranked: Vec<(GameWithDetails, f32, RecommendationReason)>,
271    limit: usize,
272) -> Vec<GameRecommendation> {
273    ranked
274        .into_iter()
275        .take(limit)
276        .map(|(g, score, reason)| GameRecommendation {
277            game_id: g.game.id,
278            score,
279            reason,
280        })
281        .collect()
282}